Hybrid Search로 키워드와 의미 검색 결합하기
Hybrid Search로 키워드와 의미 검색 결합하기
Keyword 검색은 오류 코드와 고유명사처럼 정확한 token에 강하고 dense 검색은 표현이 달라도 의미가 비슷한 문서를 찾는 데 강하다. Hybrid Search는 두 점수를 단순히 더하는 기능이 아니라, 서로 다른 후보 목록을 충분히 수집하고 권한·최신성 filter를 적용한 뒤 중복 문서를 하나로 합치는 검색 pipeline이다. 점수 범위가 다르면 정규화하거나, 점수 자체를 비교하지 않는 Reciprocal Rank Fusion으로 순위를 결합한다.
목차
- #두 검색 방식이 서로 다른 정답을 찾는다
- #Lexical과 Dense의 역할을 분리한다
- #Hybrid Search의 전체 Pipeline
- #BM25 점수와 Cosine 점수는 바로 더할 수 없다
- #점수 정규화 후 가중합
- #순위만 사용하는 Reciprocal Rank Fusion
- #RRF의 계산을 직접 따라가 보기
- #Candidate 수가 최종 품질의 상한을 만든다
- #Query 유형에 따라 가중치를 바꾼다
- #정확 일치와 Filter를 구분한다
- #중복 Chunk와 Parent 문서를 합친다
- #재구성한 TypeScript Hybrid Retriever
- #검색 Engine 요청 예제
- #Reranker와 Context 조립의 위치
- #Fallback과 부분 실패를 설계한다
- #평가셋과 Ablation Test
- #운영 지표와 Debug 정보
- #마무리
- #참고 자료
- #관련 노트
두 검색 방식이 서로 다른 정답을 찾는다
사내 운영 문서에서 다음 질문을 검색한다고 하자.
Query: PAY-1042가 발생하면 같은 요청을 다시 보내도 되나요?
Keyword 검색은 PAY-1042가 정확히 포함된 문서를 잘 찾는다.
Lexical ranking
1. PAY-1042 중복 승인 오류 처리
2. PAY-1042 지표 대시보드
3. PAY 오류 코드 전체 목록
Dense 검색은 “같은 요청을 다시 보낸다”와 “재시도”, “중복 실행”의 의미를 연결한다.
Dense ranking
1. 결제 API의 멱등성 Key 사용법
2. Timeout 뒤 결과 확인 절차
3. PAY-1042 중복 승인 오류 처리
첫 목록은 정확한 code를 놓치지 않지만 문제 해결에 필요한 일반 원리를 낮게 평가할 수 있다. 두 번째 목록은 의미적 근거를 찾지만 비슷한 다른 오류 문서가 섞일 수 있다.
flowchart LR
Q[PAY-1042 재시도?] --> L[Lexical Search]
Q --> D[Dense Search]
L --> L1[정확한 오류 문서]
D --> D1[멱등성 설명]
L1 --> F[Fusion]
D1 --> F
F --> C[정확한 오류 + 의미적 근거]Hybrid Search는 둘 중 평균적으로 좋은 하나를 고르는 것이 아니라 서로 다른 실패 방식을 가진 검색기의 후보를 결합해 recall을 높인다.
Lexical과 dense가 실제로 다른 relevant document를 찾는지 확인한다. 두 목록이 거의 같다면 hybrid의 추가 latency와 복잡도가 이익을 만들지 못할 수 있다.
Lexical과 Dense의 역할을 분리한다
각 검색기는 잘하는 신호가 다르다.
| Query 또는 문서 특성 | Lexical | Dense |
|---|---|---|
| 오류 코드·SKU·함수명 | 강함 | 놓칠 수 있음 |
| 따옴표 안 정확 문구 | 강함 | 정확성 보장 없음 |
| 동의어·자연어 표현 차이 | 약할 수 있음 | 강함 |
| 희귀 고유명사 | 강함 | Domain에 따라 불안정 |
| 긴 자연어 질문 | term 선택 필요 | 의도 표현에 유리 |
| 숫자·version | 정확 token에 유리 | 대소·동일성 보장 없음 |
| 오타 | analyzer에 따라 다름 | 일부 견고할 수 있음 |
| 언어 교차 검색 | 번역·동의어 필요 | 다국어 model이면 가능 |
Lexical 검색도 단순 문자열 포함은 아니다. Analyzer가 tokenization, lowercase, 형태소 처리와 synonym을 적용하고 BM25 같은 scoring이 term frequency와 document frequency를 반영한다.
희귀 term이 query와 document에 함께 등장
-> 높은 lexical 신호
모든 문서에 흔한 term이 등장
-> 낮은 구분력
Dense 검색은 Embedding 유사도만으로 검색하면 부족한 이유에서 살펴본 것처럼 paraphrase를 연결하지만 exact code, 최신성과 권한을 자체적으로 보장하지 않는다.
두 검색기를 경쟁 관계가 아니라 독립적인 candidate generator로 보면 설계가 단순해진다.
Hybrid Search의 전체 Pipeline
Hybrid Search는 점수 두 개를 더하는 한 줄보다 앞뒤 단계가 더 중요하다.
flowchart LR
Q[Raw Query] --> P[Parse & Normalize]
P --> X[Exact Token 추출]
P --> E[Query Embedding]
X --> B[BM25 Candidates]
E --> V[Vector Candidates]
M[권한·Tenant·Version Filter] --> B
M --> V
B --> F[Fusion & Dedup]
V --> F
F --> R[Reranker]
R --> A[Parent/Neighbor 확장]
A --> C[Context Budget 조립]각 단계의 입력과 출력을 관찰할 수 있어야 한다.
type RetrievalTrace = {
queryClass: string;
exactTokens: string[];
lexicalCandidateCount: number;
denseCandidateCount: number;
overlapCount: number;
fusionMethod: string;
rerankedCount: number;
finalChunkIds: string[];
};
최종 답이 틀렸을 때 어느 retriever가 정답을 찾았는지, fusion에서 밀렸는지, reranker가 뒤집었는지 확인할 수 있다.
BM25 점수와 Cosine 점수는 바로 더할 수 없다
처음 구현할 때 다음 식을 쓰기 쉽다.
final = 0.4 × BM25 + 0.6 × cosine
문제는 두 점수의 범위와 의미가 다르다는 것이다.
BM25 scores: 18.2, 11.4, 7.9, 3.1
Cosine: 0.86, 0.82, 0.79, 0.74
정규화 없이 더하면 BM25가 사실상 모든 순위를 결정한다. BM25 점수 자체도 query term 수, corpus와 shard 통계에 따라 달라진다. Cosine 분포도 embedding model과 corpus마다 달라진다.
또한 BM25 18과 9의 차이와 cosine 0.86과 0.78의 차이가 같은 관련성 차이를 뜻하지 않는다. 가중치 숫자가 0.4와 0.6이어도 실제 영향력은 그렇게 나뉘지 않는다.
Fusion 뒤 score를 cosine score column에 저장하면 threshold와 dashboard 해석이 깨진다. dense_score, lexical_score, fusion_score, reranker_score를 별도 필드로 둔다.
점수 정규화 후 가중합
Score-based fusion은 각 목록의 점수를 공통 범위로 바꾼 뒤 가중합한다.
Min-max normalization의 단순 형태는 다음과 같다.
normalized(s) = (s - min) / (max - min)
function minMaxNormalize(scores: number[]): number[] {
const min = Math.min(...scores);
const max = Math.max(...scores);
if (max === min) return scores.map(() => 1);
return scores.map((score) => (score - min) / (max - min));
}
fusionScore(d)
= lexicalWeight × normalizedLexical(d)
+ denseWeight × normalizedDense(d)
장점은 두 문서 사이 score margin과 query 유형별 가중치를 반영할 수 있다는 것이다. 단점도 있다.
- Candidate 목록의 극단값에 민감하다.
- 목록마다 min/max를 계산하면 같은 문서의 점수가 query마다 다른 의미를 갖는다.
- 한 retriever에 없는 문서를 0점으로 둘지 결정해야 한다.
- ANN과 lexical score 분포가 model·index 변경 때 달라진다.
- Weight를 labeled data로 조정하고 다시 검증해야 한다.
Z-score나 query별 calibration 같은 다른 방식도 있지만, normalization이 관련 확률을 자동으로 만들어 주지는 않는다.
type ScoreFusionConfig = {
lexicalWeight: number;
denseWeight: number;
normalization: "MIN_MAX" | "Z_SCORE";
version: string;
};
Score margin이 중요한 domain이고 충분한 evaluation label이 있을 때 가중합을 검토할 수 있다.
순위만 사용하는 Reciprocal Rank Fusion
RRF는 서로 다른 raw score를 직접 비교하지 않고 각 목록의 순위를 사용한다.
RRFscore(d) = Σ 1 / (k + rank_r(d))
rank_r(d)는 retriever r에서 문서 d의 1-based 순위이고, k는 상위 순위 차이의 영향을 완화하는 상수다. 원 논문은 pilot에서 k=60을 사용했지만 이를 모든 corpus의 절대 최적값으로 취급해서는 안 된다.
type RankedHit = {
documentId: string;
rank: number;
};
function rrfScore(
rankings: RankedHit[][],
rankConstant = 60,
): Map<string, number> {
const scores = new Map<string, number>();
for (const ranking of rankings) {
for (const hit of ranking) {
const contribution = 1 / (rankConstant + hit.rank);
scores.set(
hit.documentId,
(scores.get(hit.documentId) ?? 0) + contribution,
);
}
}
return scores;
}
RRF의 장점은 다음과 같다.
- BM25와 cosine의 score 범위를 맞출 필요가 없다.
- Retriever implementation이 바뀌어도 rank만 있으면 된다.
- 두 목록 모두에서 높은 문서가 자연스럽게 올라온다.
- Labeled data 없이 강한 baseline을 만들기 쉽다.
하지만 score margin을 버린다. 1위와 2위 점수가 거의 같은 경우와 압도적으로 다른 경우를 동일한 rank 차이로 본다. Candidate 깊이와 k에도 영향을 받는다.
RRF의 계산을 직접 따라가 보기
두 검색 결과가 다음과 같다고 하자.
| Rank | Lexical | Dense |
|---|---|---|
| 1 | 문서 A | 문서 C |
| 2 | 문서 B | 문서 A |
| 3 | 문서 D | 문서 E |
| 4 | 문서 C | 문서 F |
설명을 단순하게 하기 위해 k=10을 사용한다.
A = 1/(10+1) + 1/(10+2) = 0.1742
C = 1/(10+4) + 1/(10+1) = 0.1623
B = 1/(10+2) = 0.0833
D = 1/(10+3) = 0.0769
E = 1/(10+3) = 0.0769
F = 1/(10+4) = 0.0714
문서 A는 lexical 1위와 dense 2위에 모두 등장해 최종 1위가 된다. Dense에서 1위인 C도 lexical 4위의 지지를 받아 2위가 된다.
function fuseRrf(
lexical: SearchHit[],
dense: SearchHit[],
rankConstant: number,
): FusedHit[] {
const byId = new Map<string, FusedHit>();
const rankings = [
{ source: "lexical", hits: lexical },
{ source: "dense", hits: dense },
] as const;
for (const { source, hits } of rankings) {
hits.forEach((hit, index) => {
const current = byId.get(hit.documentId) ?? {
documentId: hit.documentId,
rrfScore: 0,
sources: [],
};
current.rrfScore += 1 / (rankConstant + index + 1);
current.sources.push({ source, rank: index + 1, rawScore: hit.score });
byId.set(hit.documentId, current);
});
}
return [...byId.values()].sort((a, b) => b.rrfScore - a.rrfScore);
}
Tie가 생기면 stable한 규칙을 둔다. 예를 들어 exact match 수, 더 좋은 최소 rank, 최신 source priority와 document ID 순으로 결정한다. 실험마다 결과가 흔들리지 않아야 cache와 regression test가 안정적이다.
Candidate 수가 최종 품질의 상한을 만든다
Fusion은 입력 목록에 없는 문서를 복구할 수 없다.
Lexical top 10: 정답 rank 18 -> 입력에 없음
Dense top 10: 정답 rank 23 -> 입력에 없음
Fusion: 정답을 만들 수 없음
최종 8개 context만 필요하더라도 각 retriever에서는 top 50이나 top 100을 가져와 fusion과 reranking할 수 있다. Candidate를 늘리면 recall은 올라갈 수 있지만 latency, network payload와 reranker 비용이 증가한다.
| 단계 | 예시 개수 | 목적 |
|---|---|---|
| Lexical 후보 | 50 | Exact와 희귀 term recall |
| Dense 후보 | 50 | Semantic recall |
| Dedup 후 fusion | 최대 100 | 통합 순위 |
| Rerank | 상위 40 | 정밀 관련성 |
| Context | 6~10 | Token budget에 맞춘 근거 |
숫자는 설명용이다. 실제 evaluation으로 정한다.
RRF에서는 rank_window_size가 특히 중요하다. 한 목록의 51위 문서는 window가 50이면 아무 기여를 하지 않는다. 최종 size만 튜닝하고 candidate window를 고정하면 개선 상한이 낮다.
Query 유형에 따라 가중치를 바꾼다
오류 code가 있는 query와 자연어 고민 질문에 같은 fusion policy를 쓰지 않아도 된다.
type QueryClass =
| "EXACT_IDENTIFIER"
| "QUOTED_PHRASE"
| "NATURAL_LANGUAGE"
| "MIXED"
| "STRUCTURED_LOOKUP";
type RetrievalPlan = {
lexicalCandidates: number;
denseCandidates: number;
fusion: "RRF" | "WEIGHTED_SCORE";
lexicalWeight?: number;
denseWeight?: number;
};
function planFor(queryClass: QueryClass): RetrievalPlan {
switch (queryClass) {
case "EXACT_IDENTIFIER":
return {
lexicalCandidates: 80,
denseCandidates: 20,
fusion: "WEIGHTED_SCORE",
lexicalWeight: 0.8,
denseWeight: 0.2,
};
case "NATURAL_LANGUAGE":
return {
lexicalCandidates: 40,
denseCandidates: 60,
fusion: "RRF",
};
default:
return {
lexicalCandidates: 50,
denseCandidates: 50,
fusion: "RRF",
};
}
}
이 숫자 역시 가상값이다. Query classifier가 틀릴 수 있으므로 한 검색기를 완전히 끄기보다 candidate 수나 weight를 조정하는 방식이 안전하다.
따옴표 phrase나 exact code는 lexical must로 강제할 수도 있다. 그러나 사용자가 code를 오타 냈다면 결과가 0이 될 수 있으므로 exact attempt가 실패할 때 완화된 fallback을 명시한다.
정확 일치와 Filter를 구분한다
PAY-1042를 강하게 선호하는 것은 relevance 신호다. 현재 tenant와 접근 권한을 제한하는 것은 반드시 지켜야 할 filter다.
Should / boost
- exact error code
- title match
- preferred language
Must filter
- tenant ID
- access group
- effective date
- document status = active
권한을 낮은 score penalty로 구현하면 unauthorized 문서가 후보에 남는다. Lexical과 dense 양쪽에 동일한 mandatory filter를 적용한다.
type MandatoryFilter = {
tenantId: string;
accessGroups: string[];
effectiveAt: string;
sourceStatus: "ACTIVE";
};
두 index의 metadata가 달라 같은 filter를 적용할 수 없다면 hybrid 이전에 schema를 정리한다. 한쪽에서 구문이 지원되지 않는다고 post-filter만 하면 candidate recall과 보안 경계가 달라질 수 있다.
중복 Chunk와 Parent 문서를 합친다
Lexical과 dense가 같은 chunk를 찾으면 ID로 합치면 된다. 더 어려운 경우는 서로 다른 child chunk가 같은 parent section을 가리키는 경우다.
Lexical -> parent A / child 2
Dense -> parent A / child 3
Dense -> parent B / child 1
Child level에서 fusion한 뒤 context assembly에서 parent A를 한 번만 확장한다. Parent를 먼저 합치면 서로 다른 child의 evidence가 있다는 사실을 score에 반영할 수 있다.
type FusedChunk = {
chunkId: string;
parentId: string;
fusionScore: number;
sources: Array<{ retriever: string; rank: number }>;
};
function selectParents(chunks: FusedChunk[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const chunk of chunks) {
if (seen.has(chunk.parentId)) continue;
seen.add(chunk.parentId);
result.push(chunk.parentId);
}
return result;
}
한 parent의 child가 많이 검색됐다는 이유로 score가 과도하게 누적될 수 있다. Parent당 최고 child, 상위 N개 합 또는 diminishing return을 evaluation으로 선택한다.
재구성한 TypeScript Hybrid Retriever
다음 코드는 실제 프로젝트 구현이 아니라 pipeline의 책임을 보여 주는 예시다.
type HybridSearchInput = {
query: string;
queryClass: QueryClass;
filter: MandatoryFilter;
finalCandidates: number;
};
type SearchHit = {
chunkId: string;
documentId: string;
parentId: string;
score: number;
};
async function hybridSearch(input: HybridSearchInput): Promise<FusedHit[]> {
const plan = planFor(input.queryClass);
const queryVectorPromise = embeddings.encodeQuery(input.query);
const lexicalPromise = lexicalIndex.search({
query: input.query,
filter: input.filter,
size: plan.lexicalCandidates,
});
const densePromise = queryVectorPromise.then((vector) =>
vectorIndex.search({
vector,
filter: input.filter,
size: plan.denseCandidates,
}),
);
const [lexical, dense] = await Promise.all([lexicalPromise, densePromise]);
const fused = plan.fusion === "RRF"
? fuseRrf(lexical, dense, 60)
: fuseNormalizedScores(lexical, dense, {
lexicalWeight: plan.lexicalWeight!,
denseWeight: plan.denseWeight!,
});
return fused.slice(0, input.finalCandidates);
}
Parallel 실행은 latency를 줄이지만 각 검색의 timeout과 실패 정책이 필요하다. Embedding API가 느리면 lexical 결과부터 준비될 수 있다. 사용자 경험상 progressive result가 필요한지, 최종 fusion 전에는 결과를 노출하지 않을지도 결정한다.
각 결과에는 raw score와 rank를 보존한다.
type FusedHit = {
documentId: string;
rrfScore?: number;
weightedScore?: number;
sources: Array<{
source: "lexical" | "dense";
rank: number;
rawScore: number;
}>;
};
검색 Engine 요청 예제
Engine이 hybrid와 RRF를 native로 지원하면 application에서 두 요청을 합치는 network 비용을 줄일 수 있다. 다음은 특정 운영 index가 아닌 개념 예시다.
{
"retriever": {
"rrf": {
"retrievers": [
{
"standard": {
"query": {
"bool": {
"must": [{ "match": { "body": "PAY-1042 재시도" } }],
"filter": [{ "term": { "tenant_id": "tenant-example" } }]
}
}
}
},
{
"knn": {
"field": "body_vector",
"query_vector": [0.01, -0.08, 0.12],
"k": 50,
"num_candidates": 200,
"filter": { "term": { "tenant_id": "tenant-example" } }
}
}
],
"rank_window_size": 50,
"rank_constant": 60
}
},
"size": 10
}
Vector는 축약한 가상값이다. 실제 dimension과 query embedding을 사용한다. 제품과 version별 field 이름, filter 지원 범위, pagination과 explain 제약을 공식 문서에서 확인한다.
OpenSearch처럼 normalization processor와 rank-based processor를 모두 제공하는 경우, score 차이를 보존할 필요가 있는지와 label 보유 여부를 기준으로 고른다.
Reranker와 Context 조립의 위치
Fusion은 두 retriever의 순위를 결합하지만 query와 각 candidate를 깊게 읽는 것은 아니다. 상위 candidate를 cross-encoder나 다른 reranker로 다시 정렬할 수 있다.
Lexical 50 + Dense 50
-> Dedup / Fusion 70
-> 상위 30 Rerank
-> Parent 확장과 중복 제거
-> Token budget 기준 8개 Context
Reranker에 너무 적은 candidate를 보내면 fusion에서 이미 빠진 정답을 살리지 못한다. 너무 많이 보내면 latency와 비용이 커진다. 이 trade-off는 Reranker를 추가할 때 얻는 것과 비용에서 더 자세히 다룬다.
Context 조립에서는 relevance score뿐 아니라 다양성, source authority와 token cost를 고려한다.
type ContextCandidate = {
parentId: string;
relevance: number;
authority: number;
tokenCount: number;
sourceId: string;
};
같은 문서의 유사한 chunk 8개보다 서로 다른 필수 근거를 포함한 4개가 더 나을 수 있다. Fusion 순위를 그대로 prompt 순서로 복사하지 않는다.
Fallback과 부분 실패를 설계한다
Hybrid는 dependency가 늘어난다.
Embedding API timeout
Vector index unavailable
Lexical index stale
두 index version 불일치
한 retriever가 filter를 지원하지 않음
검색 실패 정책을 query 위험도별로 정한다.
| 상황 | 가능한 처리 |
|---|---|
| Dense timeout, exact code query | Lexical-only 결과 + degraded 표시 |
| Lexical timeout, 자연어 FAQ | Dense-only 결과 + degraded 표시 |
| 권한 filter 불확실 | Fail closed |
| Index version 불일치 | 호환 version pair 사용 또는 실패 |
| 둘 다 낮은 confidence | 답변 보류 또는 추가 질문 |
type RetrievalMode = "HYBRID" | "LEXICAL_ONLY" | "DENSE_ONLY";
type HybridResult = {
mode: RetrievalMode;
degradedReasons: string[];
indexVersions: {
lexical?: string;
dense?: string;
};
hits: FusedHit[];
};
부분 실패를 조용히 정상 결과처럼 반환하면 품질 저하를 관찰할 수 없다. 로그와 답변 정책에 mode를 전달한다.
평가셋과 Ablation Test
Hybrid가 좋은지 확인하려면 세 구성을 같은 evaluation에서 비교한다.
A: Lexical only
B: Dense only
C: Hybrid
D: Hybrid + Reranker
이를 ablation test라고 볼 수 있다. Hybrid C가 A와 B보다 좋아야 추가 복잡도가 정당화된다. Query category를 나눈다.
| Category | 기대하는 강한 신호 |
|---|---|
| Exact error code | Lexical |
| Symbol과 path | Lexical |
| 자연어 paraphrase | Dense |
| 고유명사 + 설명 | Hybrid |
| 숫자와 정책 조건 | Filter + lexical |
| 다중 근거 질문 | Hybrid + reranker |
| 답이 없는 질문 | Threshold와 abstention |
평가 지표는 Hit@k, Recall@k, MRR, nDCG와 최종 답변·인용 정확도를 함께 본다. Query당 candidate와 최종 context token budget을 동일하게 맞춰야 공정하다.
{
"queryId": "eval-exact-17",
"relevantChunkIds": ["chunk-pay-1042"],
"lexicalRank": 1,
"denseRank": 17,
"hybridRank": 1,
"rerankedRank": 1,
"category": "EXACT_IDENTIFIER"
}
RRF의 rank_constant, 각 candidate window, score fusion weight와 rerank depth를 모두 한 번에 튜닝하면 evaluation set에 과적합되기 쉽다. Train/dev/held-out query를 나누고 단순한 baseline부터 개선한다.
운영 지표와 Debug 정보
최종 fusion score 하나만 로그에 남기면 각 retriever의 기여를 알 수 없다.
| 지표 | 확인할 문제 |
|---|---|
| lexical/dense candidate latency | 어느 dependency가 병목인가 |
| candidate overlap@k | 두 검색기의 다양성이 있는가 |
| unique candidates after fusion | 실질 후보 확장 정도 |
| lexical-only relevant rate | Dense가 놓친 exact query |
| dense-only relevant rate | Lexical이 놓친 paraphrase |
| rank movement after fusion | 결합의 실제 영향 |
| rank movement after rerank | Reranker의 실제 영향 |
| degraded mode rate | 한 검색기의 장애 빈도 |
| no-result by query class | Route와 threshold 문제 |
| index version mismatch | 색인 배포 일관성 |
{
"event": "hybrid_retrieval.completed",
"queryId": "qry-82",
"queryClass": "EXACT_IDENTIFIER",
"fusion": "RRF",
"rankConstant": 60,
"lexicalCandidates": 50,
"denseCandidates": 50,
"overlapAt50": 12,
"uniqueAfterFusion": 88,
"mode": "HYBRID",
"lexicalIndexVersion": "lexical-v12",
"denseIndexVersion": "dense-v12",
"elapsedMs": 83
}
Raw query에는 개인정보가 포함될 수 있으므로 장기 로그에 그대로 저장하지 않는다. Query class, 비민감 exact token category, candidate ID와 score를 중심으로 남기고 sample 접근을 통제한다.
Debug 화면에서는 문서별로 다음을 보여 주면 유용하다.
Document A
lexical rank=1 score=18.2 matched=[PAY-1042]
dense rank=3 score=0.82
RRF score=0.0323 final rank=1
reranker score=0.91 final context=yes
이 정보가 있어야 “weight를 올려 보자”가 아니라 실제 실패 원인을 근거로 조정할 수 있다.
마무리
Hybrid Search는 vector 검색에 keyword 점수를 조금 더하는 기능이 아니다. 서로 다른 장점을 가진 candidate generator를 병렬로 실행하고, 필수 filter와 중복 제거, 순위 결합, reranking과 context budget으로 이어지는 pipeline이다.
점수 의미가 다른 두 검색기를 결합할 때는 raw score를 바로 더하지 말고, 정규화된 score 또는 각 목록의 rank를 사용한다.
실무 적용 기준은 다음과 같다.
- Lexical과 dense가 어떤 query category에서 서로 다른 정답을 찾는지 측정한다.
- 두 retriever를 독립적인 candidate generator로 운영한다.
- Tenant, 권한, version과 효력 기간 filter를 양쪽에 동일하게 적용한다.
- BM25와 cosine raw score를 그대로 더하지 않는다.
- Score margin이 필요하면 normalization과 labeled weight를 사용한다.
- 단순하고 견고한 baseline이 필요하면 RRF를 검토한다.
- RRF constant와 candidate window도 evaluation으로 정한다.
- 최종 result 수보다 넓은 candidate를 수집해 recall을 확보한다.
- Exact identifier query에는 lexical 신호를 강화한다.
- Chunk ID로 합친 뒤 parent 단위 중복과 context 다양성을 처리한다.
- Fusion 뒤 필요한 수의 candidate만 reranker에 보낸다.
- 한 retriever 장애 시 degraded mode와 fail-closed 조건을 명시한다.
- Lexical-only, dense-only, hybrid와 reranked 구성을 ablation test한다.
- Raw score, rank, source와 index version을 별도 관찰한다.
Dense 검색이 keyword 검색을 대체하거나 그 반대가 되는 것이 목표가 아니다. 정확한 문자열과 의미적 표현이라는 서로 다른 증거를 보존한 채, 실제 질문에 가장 알맞은 근거 목록으로 합치는 것이 Hybrid Search의 역할이다.
참고 자료
- Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods
- Elastic Documentation - Hybrid Search
- Elastic Reference - Reciprocal Rank Fusion
- OpenSearch Documentation - Hybrid Search
- An Analysis of Fusion Functions for Hybrid Retrieval